48 — 混合 HTML 与 WebGL
简介
在本课中,我们将学习如何把 HTML 集成到 scene 中。所谓集成,就是让一个可交互的 HTML 元素跟随 scene 上某个特定的 3D 位置,使其看起来像是 WebGL 的一部分。
为了演示这一点,我们将为一个 model 添加兴趣点。这些兴趣点将用 HTML 制作,并且始终黏附在其关联的 3D 位置上。而且由于这些点是用 HTML 制作的,我们就可以用 CSS 来设计它们,添加像 :hover 这样的交互,并添加过渡效果。
初始设置
起始文件由我们在 Intro and loading progress 课程以及 Post-processing 课程中所做的工作组成,使用的是 Damaged Helmet,其作者为 Leonardo Carrion。
HTML
首先,让我们创建一个 HTML 点。在课程结束时,我们会添加更多的点。
在 /src/index.html 文件中,在 <canvas> 和加载进度条之后添加这个点:
<canvas class="webgl"></canvas>
<div class="loading-bar"></div>
<div class="point point-0">
<div class="label">1</div>
<div class="text">Lorem ipsum, dolor sit amet consectetur adipisicing elit</div>
</div> 我们放在该点内部的文本不应该在页面上可见,因为它被隐藏在 <canvas> 后面。
我们用 point 类来在 CSS 中定位所有的点,同时也用 point-0 类来在 JavaScript 中定位这个特定的元素。后续的点将使用 point-1、point-2 等等。
我们的点由一个带有 label 类的 <div> 和一个带有 text 类的 <div> 组成。
label 将是那个看起来像是黏在 model 上的小圆形数字,而 text 会在我们悬停该 label 时显示出来。
CSS
我们将一次性把整个 CSS 全部添加进去。通常你需要循序渐进地做,但我们已经知道目标是什么。
打开 /src/style.css 文件,首先把点定位到屏幕中央:
.point
{
position: absolute;
top: 50%;
left: 50%;
}
你应该能看到黑色文本出现在屏幕中央。我们从中心开始,因为 Three.js 提供的坐标中 0 就是屏幕的中心。
让我们先设计 label:
.point .label
{
position: absolute;
top: -20px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
font-family: Helvetica, Arial, sans-serif;
text-align: center;
line-height: 40px;
font-weight: 100;
font-size: 14px;
}
你应该能看到这个 label。
这里没什么难的。我们只是用 border-radius: 50%; 把元素变成圆形,用 background: #00000077; 设置了降低不透明度的黑色背景;并用 border: 1px solid #ffffff77; 对边框做了同样的处理。然后,我们把白色文字居中在元素中间。
让我们对 text 做同样的处理:
.point .text
{
position: absolute;
top: 30px;
left: -120px;
width: 200px;
padding: 20px;
border-radius: 4px;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
line-height: 1.3em;
font-family: Helvetica, Arial, sans-serif;
font-weight: 100;
font-size: 14px;
}
你应该能看到文本出现在 label 下方。
我们这里实现的效果与 label 非常相似,只是使用了固定的 border-radius、特定的 line-height 以及 padding。
现在我们已经有了两个元素,接下来准备交互。首先,我们希望文本默认是隐藏的。
把它的 opacity 设为 0:
.point .text
{
/* ... */
opacity: 0;
} 当 .point 被悬停时,我们希望文本显示出来:
.point:hover .text
{
opacity: 1;
}
当 label 被悬停时,文本应该显示出来。
与其这样生硬地出现,我们希望文本能够淡入淡出。为此,我们可以使用 transition。
把 transition 添加到 text 上,而不仅仅是在悬停时。这样,当光标离开该点时过渡也会发生:
.point .text
{
/* ... */
transition: opacity 0.3s;
}
文本应该能够淡入淡出。
我们遇到一个小问题。当文本不可见时,我们仍然可以直接悬停在它上面。
要解决这个问题,我们可以停用 text 上的指针事件。这可以通过 pointer-events CSS 属性来实现:
.point .text
{
/* ... */
pointer-events: none;
}
文本不应该能够被“悬停”。
目前,当我们悬停 label 中的数字时,光标会变成文本选择样式。要解决这个问题,我们可以用 cursor CSS 属性来更改整个 label 上的光标。
.point .label
{
/* ... */
cursor: help;
}
悬停 label 时,光标位置应该出现一个“?”。
我们几乎完成了 CSS 的工作。现在需要做的是准备一种显示和隐藏 label 的方式。所以,我们会默认隐藏它们,只有当点上存在 visible 类时才显示。
为了隐藏它们,我们将使用带 scale 的 transform,同时还会添加 transition,以便对 scale 进行动画:
.point .label
{
/* ... */
transform: scale(0, 0);
transition: transform 0.3s;
}
.point.visible .label
{
transform: scale(1, 1);
} label 应该消失了。但如果我们在 Developer Tools 面板中直接给元素添加 visible 类,就应该能看到 label 显示出来。
目前,我们先直接在 HTML 中添加 visible 类,以便给这些点定位。稍后我们会移除这个类。
<div class="point point-0 visible">
现在这个点应该是可见的。
JavaScript
现在 HTML 和 CSS 都已经准备就绪,我们可以切换到 JavaScript 了。
存储这些点
首先,我们需要一种方式来存储所有的点——即使现在只有一个。我们将使用一个对象数组,每个对象对应一个点。
每个点对象将有两个属性:3D 位置以及对 HTML 元素的引用。
创建 points 数组,并在其中放入一个点:
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
}
] 我们使用 Vector3 表示位置,并用 document.querySelector(...) 从 HTML 中获取该元素。
更新点的位置
我们将直接在 tick 函数中逐帧更新这些点的 HTML 元素。
在 tick 函数中,紧跟在更新 controls 之后,遍历 points 数组中的每个元素——即使我们只有一个。你可以使用任何循环方式,但我们这里用 for(... of ...):
const tick = () =>
{
// Update controls
controls.update()
// Go through each point
for(const point of points)
{
}
// ...
} 我们需要获取点的 3D scene 位置所对应的 2D 屏幕位置。这部分相当直接。
首先,我们需要克隆点的位置。这很重要,因为接下来的代码会直接修改这个 Vector3。要克隆位置,使用 clone() 方法:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
}
// ...
} 要获取 2D 屏幕位置,我们需要调用 project(...) 方法,并把 camera 作为参数传入:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
console.log(screenPosition.x)
}
// ...
} 你应该会在日志中看到一个接近 0 的值。如果你移动 camera,让头盔位于最左侧——使用右键拖拽 & 放置——你会得到一个接近 -1 的值。如果你移动 camera,让头盔位于最右侧,你会得到一个接近 +1 的值。
我们先专注于 x 值,稍后再处理 y。
问题在于我们不能直接这样使用这个值。我们希望能够用像素来移动 point 元素。
要从这个投影后的屏幕位置转换到屏幕上的像素,我们需要乘以渲染尺寸的一半,而我们已经有了这个值,它就在 sizes 对象中:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
console.log(translateX)
}
// ...
} 再次移动 camera,把头盔放到两侧的极远处,然后看看日志中的值。它应该有几百。
现在这个值看起来对了,让我们更新 point 元素。为此,我们将在 style 的 transform 属性中应用平移。别忘了在 translateX 函数结尾写上 px:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
point.element.style.transform = `translateX(${translateX}px)`
}
// ...
}
这个点在 X 轴上看起来移动得很好。让我们添加 Y 轴。
使用 y 属性和 sizes.height 值添加一个 translateY 变量。然后,把它应用到 transform 中:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
const translateY = screenPosition.y * sizes.height * 0.5
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
}
// ...
}
我们就快完成了,可惜 Y 轴似乎是反的。确实如此,在 CSS 中,正的 translateY 向下,而在 Three.js 中,正的 y 向上。
我们需要把 translateY 变量取负:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
const translateY = - screenPosition.y * sizes.height * 0.5
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
}
// ...
}
point 元素应该能完美地跟随它的 3D 位置。
显示和隐藏元素
最后,如果点前面有东西,我们需要隐藏这个点。
首先,移除 HTML 中的 visible 类:
<div class="point point-0"> 要测试点前面是否有东西,我们将使用 Raycaster。我们会从 camera 向该点发射一条射线。如果没有相交的 object,我们就显示该点。如果有东西,我们就测试交点的距离。如果交点比该点更远,说明 object 在该点后面,我们可以显示它。如果交点比该点更近,说明相交的 object 在该点前面,我们就隐藏它。
让我们在 tick 函数之外的任意位置创建一个 Raycaster:
const raycaster = new THREE.Raycaster() 在 tick 函数中,更新 Raycaster,使其从 camera 指向该点。为此,使用 setFromCamera(...)。
如果你还记得 Raycaster 课程,setFromCamera(...) 的第一个参数应该是一个对应屏幕上位置的 Vector2,第二个参数应该是 camera。
在 Raycaster 课程中,我们需要把光标的屏幕位置(以像素为单位)转换成适配 Three.js 坐标的值。不过在我们的情况下,我们已经有了这些值。我们可以改用 screenPosition。是的,screenPosition 是一个 Vector3,但只会用到它的 x 和 y 属性:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
// ...
}
// ...
} 我们将用 intersectObjects(...) 方法让 Raycaster 对 scene 中的每个 object 进行检测。
为此,把 scene.children 作为第一个参数,把 true 作为第二个参数。第二个参数将启用递归检测,也就是说算法还会遍历子级的子级,以及子级的子级的子级,等等:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
// ...
}
// ...
} 首先,我们可以先检测是否存在交点。如果没有交点,说明点前面没有 object,我们可以显示它。如果存在交点,我们还需要做更多的测试,但目前,我们先直接隐藏这个点。
要显示该点,我们可以用 classList.add(...) 添加 visible 类。而要隐藏该点,我们可以用 classList.remove(...) 移除 visible 类:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
point.element.classList.remove('visible')
}
// ...
}
// ...
}
不完全是我们要的效果,但我们正接近了。
问题在于只要有东西与射线相交我们就会隐藏这个点。但如果交点在该点后面,那么这个点就不应该被隐藏。
我们需要计算到该点的距离,然后计算交点的距离,并对它们进行比较。
intersectObjects(...) 方法返回一个交点数组。这些交点按距离排序,最近的排在最前。这意味着如果有多个交点,我们不必测试所有的交点,只需测试第一个即可。
要获取交点距离并不难,因为这个值已经作为 distance 属性存在于交点对象中:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
}
// ...
}
// ...
} 获取该点的距离稍难一些。我们需要从点的 position 开始,由于它是一个 Vector3,我们可以使用它的 distanceTo() 方法。
这个方法需要另一个 Vector3 作为参数。它会计算第一个和第二个之间的距离。我们可以使用 camera 的 position:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
const pointDistance = point.position.distanceTo(camera.position)
}
// ...
}
// ...
} 现在我们需要做的就是比较这两个距离。如果 intersectionDistance 小于 pointDistance,说明相交的 object 比该点更近,应当被隐藏。否则,该点应当显示:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
const pointDistance = point.position.distanceTo(camera.position)
if(intersectionDistance < pointDistance)
{
point.element.classList.remove('visible')
}
else
{
point.element.classList.add('visible')
}
}
// ...
}
// ...
}
现在一切都正常工作了。
等待 scene 准备就绪
我们现在遇到的问题是,scene 加载时这些关键点是可见的。一个简单的解决方法是创建一个初始为 false 的变量,并在一切就绪后把它设为 true。在 tick 函数中,只有当这个变量为 true 时我们才更新这些点。
创建一个初始为 false 的 sceneReady 变量,并在 loadingManager 的成功函数中,在 2000ms 之后把它设为 true:
let sceneReady = false
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
// ...
window.setTimeout(() =>
{
sceneReady = true
}, 2000)
},
// ...
) 最后,在 tick 函数中,把整个点循环放入一个 if 语句中:
const tick = () =>
{
// ...
if(sceneReady)
{
for(const point of points)
{
// ...
}
}
// ...
}
这些点应该只在 scene 准备就绪、并且开场动画几乎完全消失时才显示。
添加更多的点
就是这样!我们的点运行得很好。现在我们可以添加更多的点,并把这些文本改成更有说服力的内容。
在 HTML 中:
<div class="point point-0">
<div class="label">1</div>
<div class="text">Front and top screen with HUD aggregating terrain and battle informations.</div>
</div>
<div class="point point-1">
<div class="label">2</div>
<div class="text">Ventilation with air purifier and detection of environment toxicity.</div>
</div>
<div class="point point-2">
<div class="label">3</div>
<div class="text">Cameras supporting night vision and heat vision with automatic adjustment.</div>
</div> 在 JavaScript 中:
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
},
{
position: new THREE.Vector3(0.5, 0.8, - 1.6),
element: document.querySelector('.point-1')
},
{
position: new THREE.Vector3(1.6, - 1.3, - 0.7),
element: document.querySelector('.point-2')
}
]
进阶
我们这里所做的只是众多方法中的一种。你可能会有不同的做法,甚至可能更好。这取决于你、项目、动画、性能、代码需要多灵活等等。
性能方面也有改进空间。目前我们在每一帧都更新所有的点。我们可以只更新可见的那些。
请记住,将 HTML 与 WebGL 结合通常对性能不利。尽量避免这样做,如果你别无选择,请留意帧率,并定期在不同的设备上进行测试。